#!/usr/bin/python3
# -*- coding: utf-8; tab-width: 4; indent-tabs-mode: t -*-

import os
import json
import copy
import glob
import bnetd
import socket
import ipaddress
from datetime import datetime


class SsObConnType(bnetd.ObConnPluginBase):

    def __init__(self, plugin_id, plugin_description):
        super().__init__(plugin_id, plugin_description)
        self._context = None
        self._connDict = {}

    @property
    def need_temp_directory(self):
        return True

    @property
    def need_data_directory(self):
        return True

    def set_context(self, context):
        assert self._context is None and context is not None
        self._context = context

    async def on_egress_changed(self, egresses, geolocation):
        dirList = {
            self._context.LIB_OBCONN_DIR: bnetd.PluginObjectSource.LIB,
            self._context.CFG_OBCONN_DIR: bnetd.PluginObjectSource.CFG,
        }

        # make available
        uuidSet = set()
        for dir, src in dirList.items():
            for fullfn in glob.glob(os.path.join(dir, "*.ss.cfg")):
                cfg = self._cfgParseFullfnAndCheck(fullfn)
                uuid = cfg["uuid"]
                if uuid not in self._connDict:
                    obj = SsObConn(self, src, cfg, os.path.join(self._context["temp-directory"], uuid), os.path.join(self._context["data-directory"], uuid))
                    if self._context.settings.obconn_is_in_gateway(obj) == self._context.is_in_gateway():
                        if self._simpleMatchEgress(egresses, obj.server_hostname, obj.server_port):
                            await self._context.on_obconn_available(obj)
                            self._connDict[uuid] = obj
                            uuidSet.add(uuid)
                else:
                    obj = self._connDict[uuid]
                    if self._simpleMatchEgress(egresses, obj.server_hostname, obj.server_port):
                        uuidSet.add(uuid)

        # make unavailable
        for uuid in set(self._connDict) - uuidSet:
            await self._context.on_obconn_unavailable(uuid)
            del self._connDict[uuid]

    @classmethod
    def _simpleMatchEgress(cls, egresses, server, port):
        try:
            server = ipaddress.ip_address(server)
            bIp = True
        except ValueError:
            bIp = False

        srvList = ["shadowsocks", "*"] if port == 12345 else ["*"]

        for e in egresses:
            if bIp:
                if isinstance(e, bnetd.EgressIpHost):
                    if server in e.target_prefix and all(server not in ipaddress.ip_network(x) for x in e.exclude_list):
                        return True
                elif isinstance(e, bnetd.EgressIpService):
                    if server in e.target_prefix and all(server not in ipaddress.ip_network(x) for x in e.exclude_list) and e.service in srvList:
                        return True
            else:
                if isinstance(e, bnetd.EgressDomainHost):
                    if Util.isSubDomain(server, e.target_domain):
                        return True
                elif isinstance(e, bnetd.EgressDomainService):
                    if Util.isSubDomain(server, e.target_domain) and e.service in srvList:
                        return True
        return False

    @staticmethod
    def _cfgParseFullfnAndCheck(cfgPath):
        # read cfg
        cfg = None
        try:
            with open(cfgPath) as f:
                cfg = json.load(f)
        except json.JSONDecodeError:
            raise bnetd.InvalidCfgError(cfgPath, "illegal format")

        # check cfg
        if True:
            cfg2 = copy.deepcopy(cfg)

            if "uuid" in cfg2:
                del cfg2["uuid"]
            else:
                raise bnetd.InvalidCfgError(cfgPath, "missing uuid property")

            if "name" in cfg2:
                del cfg2["name"]
            else:
                raise bnetd.InvalidCfgError(cfgPath, "missing name property")

            if "description" in cfg2:
                del cfg2["description"]

            if "in-gateway" in cfg2:
                if cfg2["in-gateway"] not in ["true", "false"]:
                    raise bnetd.InvalidCfgError(cfgPath, 'invalid value "%s" for in-gateway property' % (cfg2["in-gateway"]))
                del cfg2["in-gateway"]

            if "auto-connect" in cfg2:
                if cfg2["auto-connect"] not in ["true", "false"]:
                    raise bnetd.InvalidCfgError(cfgPath, 'invalid value "%s" for auto-connect property' % (cfg2["auto-connect"]))
                del cfg2["auto-connect"]

            if "server-hostname" in cfg2:
                del cfg2["server-hostname"]

            if "server-port" in cfg2:
                try:
                    value = int(cfg2["server-port"])
                    if value <= 0 or value >= 65536:
                        raise ValueError("out of range")
                    del cfg2["server-hostname"]
                except ValueError:
                    raise bnetd.InvalidCfgError(cfgPath, 'invalid value "%s" for server-port property' % (cfg2["server-port"]))

            if "password" in cfg2:
                del cfg2["password"]
            
            if "method" in cfg2:
                if not Util.isValidShadowsocksMethod(cfg2["method"]):
                    raise bnetd.InvalidCfgError(cfgPath, 'invalid value "%s" for method property' % (cfg2["method"]))
                del cfg2["method"]

            if len(cfg2) > 0:
                raise bnetd.InvalidCfgError(cfgPath, "unknown property: %s" % (list(cfg2.keys())[0]))

        return cfg


class SsObConn(bnetd.ObConnBase):

    def __init__(self, plugin, src, cfg, tmpDir, dataDir):
        super().__init__(plugin, src)
        self._cfg = cfg
        self._tmpDir = tmpDir
        self._dataDir = dataDir

    @property
    def server_hostname(self):
        return self._cfg["server-hostname"]

    @property
    def server_port(self):
        return self._cfg.get("port", 12345)

    def get_name(self):
        return self._cfg["name"]
    
    def get_description(self):
        return self._cfg.get("description", "")

    def get_egress_depends(self):
        if Util.isIpv4Addr(self._cfg["server-hostname"]) or Util.isIpv6Addr(self._cfg["server-hostname"]):
            s = 'ip-port="%s:%d"' % (self._cfg["server-hostname"], self._cfg["server-port"])
        else:
            s = 'domain-port="%s:%d"' % (self._cfg["server-hostname"], self._cfg["server-port"])
        return bnetd.EgressDependency(s)

    def get_egress_provides(self):
        return [
            bnetd.EgressIpService("0.0.0.0/0", "web"),
            bnetd.EgressDomainService(".", "web", exclude_list=[".i2p", ".union"]),
        ]

    def is_in_gateway(self):
        return self._cfg.get("in-gateway", "false") == "true"

    def is_auto_connect(self):
        return self._cfg.get("auto-connect", "false") == "true"

    async def activate(self, stage_id, stage_data):
        assert stage_id == "out-changed"

        dtNow = datetime.now().strftime("%Y-%m-%d_%H:%M:%S")

        data = {
            "server": self._cfg["server-hostname"],
            "server_port": self._cfg["server-port"],
            "local_address": "127.0.0.1",                   # FIXME: should be gateway ip in stage_data
            "local_port": Util.getFreeSocketPort("tcp"),
        }
        if "password" in self._cfg:
            data["password"] = self._cfg["password"]
        if "method" in self._cfg:
            data["method"] = self._cfg["method"]

        cfgFullfn = os.path.join(self._tmpDir, "client.json")
        with open(cfgFullfn, "w") as f:
            buf = json.dumps(data, indent=4)

        sslocal -c %s > out.%s

        # FIXME:
        # should ensure that i2p website can be accessed by proxy, using await
        # should ensure that popular website can be accessed by proxy if outproxy is set, using await
        # timeout is 10 minutes

    async def deactivate(self):
        assert False

    async def dispose(self):
        self._proc.terminate()
        await self._proc.wait()
        del self._proc


class Util:

    @staticmethod
    def isValidShadowsocksMethod(method):
        validMethod = [
            "aes-128-gcm",
            "aes-192-gcm",
            "aes-256-gcm", 
            "chacha20-ietf-poly1305", 
            "xchacha20-ietf-poly1305"
        ]
        return method in validMethod

    @staticmethod
    def getFreeSocketPort(portType):
        if portType == "tcp":
            stlist = [socket.SOCK_STREAM]
        elif portType == "udp":
            stlist = [socket.SOCK_DGRAM]
        elif portType == "tcp+udp":
            stlist = [socket.SOCK_STREAM, socket.SOCK_DGRAM]
        else:
            assert False

        for port in range(10000, 65536):
            bFound = True
            for sType in stlist:
                s = socket.socket(socket.AF_INET, sType)
                try:
                    s.bind((('', port)))
                except socket.error:
                    bFound = False
                finally:
                    s.close()
            if bFound:
                return port

        raise Exception("no valid port")

    @staticmethod
    def isIpv4Addr(s):
        try:
            ipaddress.IPv4Address(s)
            return True
        except ValueError:
            return False

    @staticmethod
    def isIpv6Addr(s):
        try:
            ipaddress.IPv6Address(s)
            return True
        except ValueError:
            return False

    @staticmethod
    def isSubDomain(sub, parent):
        sub = sub.lower().strip()
        parent = parent.lower().strip()
        if parent == ".":
            return True
        else:
            if parent.endswith("."):
                return sub.endswith(parent)
            else:
                return sub.endswith(f".{parent}")
